/**
 * This file is released under the GNU General Public License.
 * Refer to the COPYING file distributed with this package.
 *
 * Copyright (c) 2008-2009 WURFL-Pro srl
 */
package net.sourceforge.wurfl.core.resource;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.zip.GZIPInputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;

import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;

import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.SystemUtils;
import org.apache.commons.lang.Validate;
import org.apache.commons.lang.text.StrBuilder;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.xml.sax.Attributes;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

/**
 * XMLResource
 * 
 * <p>
 * XMLResource represent a source of wurfl repository backed by XML file. XML
 * file can be root file or patch. The XML file can be compresses by gzip or
 * zip. In case of zip, the first entry will be processed.
 * </p>
 * 
 * <p>
 * The given path can be URL or filesystem path. The URL support classpath
 * scheme to load file from java classpath.
 * </p>
 * 
 * @author WURFL-PRO SRL, Rome, Italy
 * @version $Id: XMLResource.java 1045 2009-03-09 15:52:53Z filippo.deluca $
 */
public class XMLResource implements WURFLResource {

	private static final Log log = LogFactory.getLog(XMLResource.class);

	private URI uri;

	private InputStream stream;

	/**
	 * Build Resource by path. The path can be URI or filesystem path.
	 * 
	 * @param path
	 *            The path of source file.
	 */
	public XMLResource(String path) {

		Validate.notEmpty(path, "The path must be not empty");

		try {
			uri = createURI(path);
		} catch (URISyntaxException e) {
			throw new WURFLResourceException(this, e);
		}

	}

	/**
	 * Build resource by File.
	 * 
	 * @param file
	 *            The source File
	 */
	public XMLResource(File file) {

		Validate.notNull(file, "The file must be not null");

		uri = file.toURI();
	}

	/**
	 * Build resource by URI. It handle the 'classpath' schema also.
	 * 
	 * @param uri
	 *            The source URI
	 */
	public XMLResource(URI uri) {

		Validate.notNull(uri, "The URI must be not null");

		this.uri = uri;
	}

	/**
	 * Build resource by InputStream. The builded resource can be reloadable
	 * only if the given stream is resettable (the maskSupported method returns
	 * true).
	 * 
	 * @param stream
	 *            The source stream.
	 */
	public XMLResource(InputStream stream) {

		Validate.notNull(stream, "The stream must be not null");

		this.stream = stream;
	}

	// Access methods *****************************************************

	/**
	 * {@inheritDoc}
	 */
	public ResourceData getData() {

		if (stream == null) {
			if (uri != null) {
				stream = openInputStream(uri);
			} else {
				throw new WURFLResourceException(this,
						"The resource can not be read, the stream is null");
			}
		}

		ResourceData data = readData(stream);

		if (stream.markSupported()) {
			try {
				stream.reset();
			} catch (IOException e) {
				releaseStream();
			}
		} else {
			releaseStream();
		}

		return data;
	}

	/**
	 * {@inheritDoc}
	 * 
	 * If this resource is created using InputStream, the info will return
	 * Stream resource'.
	 */
	public String getInfo() {

		String info = null;

		if (uri != null) {
			info = uri.toString();
		} else {
			info = "Stream resource";
		}

		return info;
	}

	/**
	 * {@inheritDoc}
	 */
	public void release() {
		if (stream != null) {
			releaseStream();
		}

		uri = null;
	}

	/**
	 * Return if this resource can be reloaded.
	 * 
	 * @return true if can be reloaded, false otherwise.
	 */
	public boolean isReloadable() {

		boolean isStreamResettable = stream != null && stream.markSupported();

		return isStreamResettable || uri != null;
	}

	// Helper methods *****************************************************

	/**
	 * Creates a URI from the given String. If the path can be an URI
	 * representation or a filesystem path. It handle the Windows path also.
	 */
	public static URI createURI(String path) throws URISyntaxException {

		assert StringUtils.isNotBlank(path) : "The path must be not blank";

		URI createdURI = null;

		StrBuilder workingPathBuilder = new StrBuilder();
		workingPathBuilder.append(path);
		workingPathBuilder.replaceAll(" ", "%20");

		// FIXME where the path must be normalized?
		if (SystemUtils.IS_OS_WINDOWS && StringUtils.contains(path, "\\")) {
			log.debug("Encoding windows URI");
			workingPathBuilder.replaceAll("\\\\", "/");
		}

		// The path don't represent an URI
		if (!workingPathBuilder.contains(':')) {
			workingPathBuilder.insert(0, "file://");

		}

		// Assumes the path is an URI
		createdURI = URI.create(workingPathBuilder.toString());
		log.debug("Created URI: " + createdURI + " from path: " + path);

		return createdURI;
	}

	/**
	 * Convert spaces to &quot;%20&quot;
	 * 
	 * @param location
	 *            Location to convert.
	 * @return converted location.
	 * @deprecated use {@link XMLResource#createURI(String)}
	 */
	public static URI toURI(String location) throws URISyntaxException {
		return new URI(StringUtils.replace(location, " ", "%20"));
	}

	/**
	 * Opens the InputStream by giving URI. The given URI support the
	 * 'classpath' schema also.
	 * 
	 * @param uri
	 *            The given URI
	 * @return The InputStream opened from the given URI
	 */
	protected InputStream openInputStream(URI uri) {

		InputStream input = null;

		try {

			if (uri.getScheme().equals("classpath")) {
				StrBuilder pathBuilder = new StrBuilder();
				pathBuilder.append(uri.toString());
				pathBuilder.replaceFirst("classpath:", "");
				uri = URI.create(getClass().getResource(pathBuilder.toString())
						.toString());
			}

			URL url = uri.toURL();
			URLConnection connection = url.openConnection();

			if (StringUtils.equals("application/zip", connection
					.getContentType())) {
				ZipFile zipFile = new ZipFile(new File(uri));
				ZipEntry zipEntry = (ZipEntry) zipFile.entries().nextElement();
				input = zipFile.getInputStream(zipEntry);
			} else if (uri.getPath().endsWith("gz")) {
				input = new GZIPInputStream(connection.getInputStream());
			} else {
				input = connection.getInputStream();
			}
		} catch (Exception e) {
			log.error("Error opening stream URI:" + uri.toString());
			throw new WURFLResourceException(this, e);
		}

		log.debug("Opened stream from URI: " + uri);

		return input;
	}

	/**
	 * Read the ResourceData from given stream.
	 * 
	 * @param input
	 *            The stream to read data from.
	 * @return Read ResourceData.
	 */
	protected ResourceData readData(InputStream input) {

		WURFLSAXHandler handler = new WURFLSAXHandler();

		try {
			SAXParser parser = SAXParserFactory.newInstance().newSAXParser();
			parser.parse(input, handler);
		} catch (Exception e) {
			throw new WURFLResourceException(this, e);
		}

		String name = getInfo();
		String version = handler.getVer();
		boolean patch = handler.isPatch();
		ModelDevices devices = handler.getDevices();

		ResourceData data = new ResourceData(name, version, patch, devices);

		log.debug("Readed data: " + data);

		return data;
	}

	private void releaseStream() {
		try {
			stream.close();
		} catch (IOException e) {
			log.warn("Error closing stream");
		}

		stream = null;
	}

	// Helper classes *****************************************************

	static class WURFLSAXHandler extends DefaultHandler {

		// XML costants ***************************************************

		private static final String ELEM_WURFL = "wurfl";
		private static final String ELEM_WURFL_PATCH = "wurfl_patch";
		private static final String ELEM_DEVICE = "device";
		private static final String ELEM_DEVICES = "devices";
		private static final String ELEM_GROUP = "group";
		private static final String ELEM_CAPABILITY = "capability";
		private static final String ELEM_VERSION = "version";
		private static final String ELEM_VER = "ver";

		private static final String ATTR_DEVICE_ID = "id";
		private static final String ATTR_DEVICE_FALLBACK = "fall_back";
		private static final String ATTR_DEVICE_USERAGENT = "user_agent";
		private static final String ATTR_DEVICE_ACTUALDEVICEROOT = "actual_device_root";

		private static final String ATTR_GROUP_ID = "id";

		private static final String ATTR_CAPABILITY_NAME = "name";
		private static final String ATTR_CAPABILITY_VALUE = "value";

		// Memebers *******************************************************

		private String userAgent;

		private String deviceID;

		private String fallBack;

		private boolean actualDeviceRoot;

		private String groupID;

		private String capabilityName;

		private String capabilityValue;

		private Set userAgents;

		private Set devicesId;

		private Map capabilities;

		private Map capabilitiesByGroup;

		private ModelDevices devices;

		private String ver;

		private boolean insideVer = false;

		private boolean patch = false;

		private boolean root = false;

		// Access methods *************************************************

		public String getVer() {
			return ver;
		}

		public ModelDevices getDevices() {
			return devices;
		}

		public boolean isPatch() {
			return patch;
		}

		// Parser methods *************************************************

		public void startDocument() throws SAXException {
			super.startDocument();
			userAgents = new HashSet();
			devicesId = new HashSet();
			devices = new ModelDevices();
		}

		public void endDocument() throws SAXException {
			// Check if all devices have a valid fallback (only for root WURFL)
			// for (Iterator iterator = devicesMap.values().iterator();
			// iterator.hasNext();) {
			// ModelDevice device = (ModelDevice) iterator.next();
			// // Only for root WURFL
			// if (!"generic".equals(device.getID())) {
			// if (!devicesMap.containsKey(device.getFallBack())) {
			// StringBuffer msg = new StringBuffer();
			// msg.append("Device with device id ").append(device.getID()).append(
			// " has invalid fall back value ").append(device.getFallBack());
			// throw new WURFLParsingException(msg.toString());
			// }
			// }
			// }
		}

		public void startElement(String uri, String localName, String name,
				Attributes attributes) throws SAXException {
			if (ELEM_DEVICE.equals(name)) {
				deviceStartElement(attributes);
			} else if (ELEM_GROUP.equals(name)) {
				startGroupElement(attributes);
			} else if (ELEM_CAPABILITY.equals(name)) {
				startCapabilityElement(attributes);
			} else if (ELEM_VER.equals(name)) {
				startVerElement();
			} else if (ELEM_WURFL_PATCH.equals(name)) {
				startWurflPatchElement();
			} else if (ELEM_WURFL.equals(name)) {
				startWurflElement();
			}
		}

		public void endElement(String uri, String localName, String name)
				throws SAXException {
			if (ELEM_GROUP.equals(name)) {
				endGroupElement();
			} else if (ELEM_DEVICE.equals(name)) {
				endDeviceElement();
			} else if (ELEM_VER.equals(name)) {
				endVerElement();
			}
		}

		public void characters(char[] ch, int start, int length)
				throws SAXException {
			if (insideVer) {
				charsVer(ch, start, length);
			}
		}

		private void startWurflElement() {

			if (patch || root) {
				throw new WURFLParsingException(
						"Root element already defined: wurfl");
			}

			root = true;

		}

		private void startWurflPatchElement() {
			if (patch || root) {
				throw new WURFLParsingException(
						"Root element already defined: wurfl_patch");
			}

			patch = true;
		}

		private void startVerElement() {
			insideVer = true;
		}

		public void charsVer(char[] ch, int start, int length) {
			StrBuilder verBuilder = new StrBuilder();
			verBuilder.append(ch, start, length);

			ver = verBuilder.toString();
		}

		private void endVerElement() {
			insideVer = false;
		}

		private void deviceStartElement(Attributes attributes) {

			userAgent = attributes.getValue(ATTR_DEVICE_USERAGENT);
			deviceID = attributes.getValue(ATTR_DEVICE_ID);
			fallBack = attributes.getValue(ATTR_DEVICE_FALLBACK);
			actualDeviceRoot = Boolean.valueOf(
					attributes.getValue(ATTR_DEVICE_ACTUALDEVICEROOT))
					.booleanValue();

			// check if the user agent and device id are valid values
			if (StringUtils.isEmpty(deviceID)) {
				throw new WURFLParsingException("device id is not a valid");
			}
			if (!"generic".equals(deviceID) && StringUtils.isEmpty(userAgent)) {
				StringBuffer msg = new StringBuffer();
				msg.append("Device with id ").append(deviceID).append(
						" has an invalid user agent");
				throw new WURFLParsingException(msg.toString());
			}

			// check if the device id and the user_agent are unique
			if (devicesId.contains(deviceID)) {
				throw new WURFLParsingException("device id " + deviceID
						+ " already defined!!!");
			}

			if (userAgents.contains(userAgent)) {
				throw new WURFLParsingException("user agent [" + userAgent
						+ "] already defined");
			}

			userAgents.add(userAgent);
			devicesId.add(deviceID);

			capabilities = new HashMap();
			capabilitiesByGroup = new HashMap();
		}

		private void endDeviceElement() {

			ModelDevice device = new ModelDevice.Builder(deviceID, userAgent,
					fallBack).setActualDeviceRoot(actualDeviceRoot)
					.setCapabilities(capabilities).setCapabilitiesByGroup(
							capabilitiesByGroup).build();

			devices.add(device);
		}

		private void startCapabilityElement(Attributes attributes) {

			capabilityName = attributes.getValue(ATTR_CAPABILITY_NAME);
			capabilityValue = attributes.getValue(ATTR_CAPABILITY_VALUE);

			if (StringUtils.isEmpty(capabilityName) || null == capabilityValue) {
				throw new WURFLParsingException("device with id " + deviceID
						+ " has capability with name or value not valid");
			}

			if (capabilities.containsKey(capabilityName)) {
				throw new WURFLParsingException("The devices with id "
						+ deviceID + " define more " + capabilityName);
			}

			// intern by Kenny MacLeod
			String internCapabilityName = capabilityName.intern();
			String internGroupId = groupID.intern();

			capabilities.put(internCapabilityName, capabilityValue);
			capabilitiesByGroup.put(internCapabilityName, internGroupId);
		}

		private void startGroupElement(Attributes attributes) {

			groupID = attributes.getValue(ATTR_GROUP_ID);

		}

		private void endGroupElement() {
			/* Empty */
		}

	}

}
